iT邦幫忙

2023 iThome 鐵人賽

DAY 21
0
Mobile Development

30 天輕鬆學會 Flutter 測試系列 第 21

Day 21 測試在 Widget 打開外部連結

  • 分享至 

  • xImage
  •  

當我們在寫單元測試時,我們會需要作假外部依賴,例如:http、Dio 等呼叫 Web API 用的套件。相同的,當我們在寫 Widget 時,有時也會需要想辦法隔離一些外部套件,這個在之前的文章中有討論過,今天我們看看一些比較不一樣的例子。

url_launcher 套件

url_launcher 在開發 Flutter 的時候也是滿常用到的,這是一個 Flutter 官方開發的套件,當我想用瀏覽器開啟連結時,我們就會用 url_launcher 來幫忙處理。除此之外,像是 email 或手機號碼,也能用 url_launcher 開啟相對應的應用程式來處理。

launchUrl(Uri.parse("https://www.google.com"));

launchUrl(Uri(scheme: 'mailto', path: 'paul@gmail.com'));

而用法也很簡單,只要定義好 Uri 直接當參數呼叫 launchUrl 這個靜態方法即可。

打開規範說明頁面

大多時候,我們有 Web 也有 Mobile App 時,我們就會把網站的規範說明放在 Web 中,然後 Mobile App 使用瀏覽器或 WebView 打開這個網址,避免 Mobile App 需要在處理一次,在這邊我們就用 url_launcher 處理吧。[範例程式]

class TermAndCondition extends StatelessWidget {
  const TermAndCondition({super.key});

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        launchUrl(Uri.parse("https://www.google.com"));
      },
      child: const Center(
        child: Text(
          'Terms & Conditions',
          style: TextStyle(
            color: Colors.blue,
            decoration: TextDecoration.underline,
          ),
        ),
      ),
    );
  }
}

當我們開始測試時,寫完 pumpWidget,寫完 tap,最後當我們想驗證頁面有沒有被打開時就會卡住。我們可能會想用 Mock 的方式來驗證,但是因為 launchUrl 方法是第三方的套件,而且是使用全域方法,導致我們無法直接 Mock 它。

main() {
  testWidgets("should open t&c page when click t&c", (tester) async {
		await tester.pumpWidget(const MaterialApp(home: TermAndCondition()));

    await tester.tap(find.text("Terms & Conditions"));

    // 如何驗證

  });
}

經典 Extra And Override

為了解決,我們可以使用最經典的方式,Extra And Override 來處理,實作一個 TestTermAndCondition 繼承 TermAndCondition,覆寫並且攔截參數,最後就可以在測試中使用。

class TestTermAndCondition extends TermAndCondition {
  Uri? uri;

  @override
  void openUrl(Uri uri) {
    this.uri = uri;
  }
}

可以看到在測試中我們就可以直接比較 testTermAndCondition 的 uri 是否正確。

main() {
  testWidgets("should open t&c page when click t&c", (tester) async {
    var testTermAndCondition = TestTermAndCondition();

    await tester.pumpWidget(MaterialApp(home: testTermAndCondition));

    await tester.tap(find.text("Terms & Conditions"));

    expect(testTermAndCondition.uri, Uri.parse("https://www.google.com"));
  });
}

這個解法雖然有用,比較容易用在 StatelessWidget,而比較難在 StatefulWidget 中,因為 StatefulWidget 包含了兩個類別 Widget 類別與他相對應 State 類別。

class TermAndCondition extends StatefulWidget {
  const TermAndCondition({super.key});

  @override
  State<TermAndCondition> createState() => TermAndConditionState();
}

class TermAndConditionState extends State<TermAndCondition> {
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        openUri(Uri.parse("https://www.google.com"));
      },
      child: const Center(
        child: Text(
          'Terms & Conditions',
          style: TextStyle(
            color: Colors.blue,
            decoration: TextDecoration.underline,
          ),
        ),
      ),
    );
  }

  void openUri(Uri uri) {
    launchUrl(uri);
  }
}

這使得我們除了覆寫 TermAndCondition 之外,還得公開 TermAndConditionState 並覆寫。

class TestTermAndCondition extends TermAndCondition {
  @override
  State<TermAndCondition> createState() => TestTermAndConditionState();
}

class TestTermAndConditionState extends TermAndConditionState {
  Uri? uri;

  @override
  void openUri(Uri uri) {
    this.uri = uri;
  }
}

然後我們才能測試,但實際上要驗證的時候也是頗為麻煩,我們得找到 Element,接著從 Element 中找到存在 Element 中的 State,最後我們才能驗證 uri 是否預期。測試流程比起 StatelessWidget 來說,要複雜不只一倍。

main() {
  testWidgets("should open t&c page when click t&c", (tester) async {
    var testTermAndCondition = TestTermAndCondition();

    await tester.pumpWidget(MaterialApp(home: testTermAndCondition));

    await tester.tap(find.text("Terms & Conditions"));

    var element = tester.element<StatefulElement>(find.byWidget(testTermAndCondition)) ;
    var state = element.state as TestTermAndConditionState;
    expect(state.uri, Uri.parse("https://www.google.com"));
  });
}

Extra And Override 的缺點

Extra And Override 看似方便,其實仔細想想,就會發現並不是最好的方式。因為我們可能會在許多 Widget 都使用 launchUrl,也意味著我們可能需要在每個需要的 Widget 都做一次重複的事情,這顯然提高了測試成本。

避免直接依賴 url_launcher

在這個問題上,當然我們也可以選擇避免直接 url_launcher,製作一個 UriRepository 封裝 launchUrl 的操作。然後使用依賴注入框架取得 UriRepository 使用。

class TermAndCondition extends StatelessWidget {
  const TermAndCondition({super.key});

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        getIt<UriRepository>().open(Uri.parse("https://www.google.com"));
      },
      child: ...,
    );
  }
}

class UriRepository {
  void open(Uri uri) => launchUrl(uri);
}

這邊我們就不多做示範,相信看到這邊的觀眾應該知道該怎麼處理了。但是今天我們還想提另外一個作法,也就是使用 url_launcher 提供的 API 來注入 Mock 的 UrlLauncher,讓我們繼續看下去。

做假的 UrlLauncher

首先我們得在 dev 的相依中加入相關套件 plugin_platform_interfaceurl_launcher_platform_interface

flutter pub add --dev plugin_platform_interface
flutter pub add --dev url_launcher_platform_interface

為什麼需要這兩個套件呢?因為我們打算直接在測試中把假的 MockUrlLauncher 塞入 UrlLauncherPlatform 的 instance 單例變數,這樣就能讓測試執行到 UrlLauncher 時,使用的是 MockUrlLauncher。

UrlLauncherPlatform.instance = MockUrlLauncher();

回到最一開始的問題,需要這兩個套件的原因是 MockUrlLauncher 需要實作來自 url_launcher_platform_interface 中的 UrlLauncherPlatform,和 with 來自 plugin_platform_interface 的 MockPlatformInterfaceMixin。

class MockUrlLauncher 
			extends Mock 
			with MockPlatformInterfaceMixin 
			implements UrlLauncherPlatform {
}

為什麼需要 UrlLauncherPlatform 很好理解,因為這個就是 UrlLauncher 的介面,實作了這個介面,我們才能把 MockUrlLauncher 塞到 UrlLauncherPlatform 的 instance 中,那 MockPlatformInterfaceMixin 呢?

如果們看 MockPlatformInterfaceMixin 的原始碼會發現他什麼實作都沒有。簡單來說,MockPlatformInterfaceMixin 只是拿來標示這個類別是測試用的。

@visibleForTesting
abstract class MockPlatformInterfaceMixin implements PlatformInterface {}

當使用 MockUrlLauncher 塞到 UrlLauncherPlatform 的 instance 時,UrlLauncherPlatform 會做一些驗證,但是當塞進去的類別是 MockPlatformInterfaceMixin 時,UrlLauncherPlatform 就會跳過這些驗證,我們也就能避免測試因為這些驗證不通過而紅燈。

最後我們就得到一個 MockUrlLauncher,並在其中監聽 lauchUrl 的呼叫狀況,就能輕鬆測試傳進來的 url 是否有正確了。

class MockUrlLauncher extends Mock with MockPlatformInterfaceMixin implements UrlLauncherPlatform {
  String? url;

  @override
  Future<bool> launchUrl(String url, LaunchOptions options) async {
    this.url = url;
    return true;
  }
}

用 MockUrlLauncher 測試

最後我們只要建立 MockUrlLauncher 並它它塞到 UrlLauncherPlatform.instance 中,就能成功測試 launchUrl 的結果是否正確。

main() {
  setUp(()=> registerFallbackValue(const LaunchOptions()));

  testWidgets("should open t&c page when click t&c", (tester) async {
    var mockUrlLauncher = MockUrlLauncher();
    UrlLauncherPlatform.instance = mockUrlLauncher;

    when(() => mockUrlLauncher.launchUrl(any(), any())).thenAnswer((invocation) async => true);

    await tester.pumpWidget(const MaterialApp(home: TermAndCondition()));

    await tester.tap(find.text("Terms & Conditions"));

    verify(() => mockUrlLauncher.launchUrl("https://www.google.com", any()));
  });
}

class MockUrlLauncher extends Mock with MockPlatformInterfaceMixin implements UrlLauncherPlatform {}

與前面的測試方法相比,使用 MockUrlLauncher 除了讓測試變得十分簡單,不同 Widget 之間如果有相同的測試需求,也能重複使用 MockUrlLauncher,也不需要依賴注入框架輔助,讓測試更輕鬆。

P.S 由於這邊需要自己製作 Mock 比較方便,所以我們使用 mocktail 來測試,語法上與 mocktio 有一些小差異,但應該不至於影響閱讀測試。

小結

若以 Clean Architecture 的設計分層來說,Widget 是處於為外層的部分,也自然與框架或外部依賴最深,測試也會變得不好測。大多時候我們可以用 Extra And Override 這個萬金油來解決,但同時也必須想想,套件開發者是不是已經為我們準備好可測試的攔截點了,善用這些別人已經造好的輪子,可以除了可以省去寫大量重複程式碼的時間,也可以簡化測試,提升測試的可讀性。


上一篇
Day 20 再多的文字都比不上一張圖片
下一篇
Day 22 在測試中也會畫面跑版?
系列文
30 天輕鬆學會 Flutter 測試30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言